S260: Hetzner Multi Server Infrastructure And SandboxPlacementRouter
Documentation for Plans
S260: Hetzner Multi-Server Infrastructure and SandboxPlacementRouter
Session: 260 | Date: 2026-03-23 | Branch: session-254-259 (shared, concurrent)
Agent: Claude Code (Opus 4.6) | Status: Phase 1 complete, Phase 2 ready to build
Spec: docs/superpowers/specs/2026-03-23-hetzner-dual-server-infrastructure-design.md
MCP Decisions: dev_1774275166101_7f7cvcj (infra), dev_1774280349838_q915b9u (architecture)
1. Session Mission
Set up GoGrab's second Hetzner dedicated server (AX102-U) and design a multi-server architecture where both servers work concurrently — not as primary/fallback, but as a unified fleet with role-based workload routing.
The ultimate goal: customer sandbox VMs route to the more powerful AX102-U, executive/dev VMs route to AX42-U, git operations run on AX102-U, and if either server hits capacity, work overflows to the other.
This requires a new SandboxPlacementRouter service that replaces the current single-server SelfHostedProvider in the sandbox provider chain.
2. Work Completed
Physical Infrastructure (fully done)
- AX102-U bootstrapped from bare OS: Ubuntu 24.04, Node.js 20, NGINX, Firecracker 1.6.0, fail2ban, firewall
- Sandbox orchestrator deployed and running: port 3010, systemd service, 60 VM capacity, health status
ok - SSL configured:
ax102-01.gograb.spacewith Let's Encrypt auto-renewal - NGINX reverse proxy: All routes (/health, /sandboxes, /git, /vnc, /internal) with 50MB body limit
- Firecracker ready: Kernel + rootfs copied from AX42-U, network bridge
br-sandboxwith NAT - Git repos transferred:
chrome-space-1/conejoandchrome-space-1/tortugacopied to AX102-U - SSH key auth: AX102-U can SSH to AX42-U (ED25519 key added to AX42-U authorized_keys)
- Platform template copied:
/data/templates/platform-base-templateon AX102-U
Configuration Changes (done, uncommitted)
.env.local: Complete rewrite of Hetzner section with:SANDBOX_SERVERS_JSON— new multi-server config array (both servers with roles, priorities, API keys)SANDBOX_SERVER_URL— pointed toax102-01.gograb.space(single-server fallback)PLATFORM_GIT_SERVER_URL— pointed toax102-01.gograb.space(git moved to AX102-U)SELF_HOSTED_INFRA_JSON— AX42-U as additional server in dashboard- Server fleet documentation table in comments
proxy.conf.js:/sandbox-apitarget changed fromsandbox.gograb.spacetoax102-01.gograb.spacenginx.conf(repo): Fixed 3 issues — added missing/git/location block, increasedclient_max_body_sizeto 50m, parameterized domain withDOMAIN_PLACEHOLDERpackage.json: Addedsandbox:deploy:all,sandbox:deploy:compute,sandbox:bootstrapscripts
New Files Created
deployment/sandbox-orchestrator/bootstrap-server.sh— Full server bootstrap script with flags (--domain, --api-key, --skip-firecracker, --skip-ssl, --dry-run)deployment/sandbox-orchestrator/deploy-orchestrator-all.sh— Fleet deploy script (build once, deploy to all hosts)docs/superpowers/specs/2026-03-23-hetzner-dual-server-infrastructure-design.md— Full design spec (updated in-session)
Architecture Decisions
- Server naming:
{hardware-class}-{sequence}— roles as metadata, not identity.ax102-01,ax42-01 - AX42-U DNS kept as
sandbox.gograb.space— legacy, too many references to rename now - Git operations moved to AX102-U — more powerful CPU/NVMe for growing repos
- Separate API keys per server — blast radius containment
- SandboxPlacementRouter design — Filter→Score pattern inspired by K8s/Nomad, implements existing
SandboxProviderinterface for zero consumer changes
3. Current System State
What Works
- ax102-01 (AX102-U): Fully operational —
https://ax102-01.gograb.space/healthreturnsok, 32 CPUs, 60 VM capacity, Firecracker ready, git repos present - ax42-01 (AX42-U): Running as before —
https://sandbox.gograb.space/healthreturnsok, 16 CPUs, 30 VM capacity, Firecracker ready - Both orchestrators respond to health and dashboard-summary API calls
- Deploy scripts work:
yarn sandbox:deploy:host --host [email protected]
What Partially Works
- Infrastructure dashboard (
/infrastructure/hetzner): Shows only AX42-U becausebackend-ai-apihasn't been restarted to pick upSELF_HOSTED_INFRA_JSON. Once restarted, both will appear. - Frontend git-operations:
proxy.conf.jsupdated to ax102-01 but dev server needs restart to pick up change.
What Does NOT Work Yet
- Multi-server VM routing:
SandboxPlacementRouteris designed but NOT built. CurrentlySandboxProviderFactorystill selects ONE provider. If you start backend-ai-api now, it will useSANDBOX_SERVER_URL(ax102-01) as the single target — functional but not multi-server. - Executive VM routing to AX42-U: No code exists to route based on workload type.
- Capacity-aware overflow: No code checks server capacity before routing.
- Placement registry: No
vmId → serverIdmapping exists for sticky routing.
What Exists But Is Not Functional
SANDBOX_SERVERS_JSONenv var is defined in.env.localbut NO code reads it yet.
4. Critical Gaps
Gap 1: SandboxPlacementRouter Does Not Exist
Why it matters: Without this, both servers cannot work concurrently for VM workloads. Only one server receives VMs at a time.
Root cause: SelfHostedProvider is a single-URL HTTP client. SandboxProviderFactory selects ONE provider at startup.
Impact: Executive VMs can't be routed to AX42-U. No overflow capacity. $248/mo of hardware with only half being used for VMs.
Gap 2: No Workload Type Signal in the Sandbox Creation Path
Why it matters: Even with a router, getOrCreateSandbox(sessionId, userId) has no parameter to indicate whether this is a customer VM or executive VM.
Root cause: The SandboxProvider interface doesn't include workload type. ComputerUseGoal has persistentVm and user info but this isn't passed to the provider.
Impact: Router can't make role-based decisions without this signal.
Gap 3: Placement Map for Sticky Routing
Why it matters: Once a VM is created on ax102-01, all subsequent operations (screenshot, click, destroy) MUST go to ax102-01. Without a mapping, operations would be sent to the wrong server.
Root cause: SandboxHandle doesn't include serverId. The provider tracks sandboxes in an in-memory Map<sessionId, SandboxHandle> within a single SelfHostedProvider instance.
Impact: Operations on existing VMs would fail with 404 if sent to the wrong server.
Gap 4: No Commits from This Session
Why it matters: All changes are uncommitted due to concurrent session constraint. If the working tree is reset, all config changes and new files are lost. What to do: The next session should commit all S260 changes before doing anything else.
5. Prioritized Recommendations
Tier 1 — High Leverage / Low Effort
T1.1: Commit all S260 changes
- What:
git addthe specific files changed/created, commit with[S260.1] feat: Hetzner AX102-U setup + multi-server config - Why: Preserves all infrastructure work
- Effort: 5 minutes
- Dependencies: None
T1.2: Restart backend-ai-api to show both servers in dashboard
- What: Restart the dev backend-ai-api process so it reads the updated
.env.local - Why: Both servers will appear in
/infrastructure/hetznerdashboard immediately - Effort: 1 minute
- Dependencies: T1.1
Tier 2 — Medium Effort / High Impact
T2.1: Build SandboxPlacementRouter
- What: New service implementing
SandboxProviderinterface with multi-server routing - Files to create:
sandbox-server-client.ts— HTTP client for one server (extracted from SelfHostedProvider)sandbox-server-registry.ts— ParsesSANDBOX_SERVERS_JSON, manages server configssandbox-health-monitor.ts— Per-server health polling with circuit breakersandbox-placement-map.ts—vmId → serverIdmapping (in-memory, rebuilt on restart from each server's/sandboxeslist)sandbox-placement-router.ts— ImplementsSandboxProvider, filter→score→place logic
- Why: Enables concurrent multi-server VM routing — the whole point of having two servers
- Effort: 3-4 hours
- Dependencies: None (can be built and tested independently)
T2.2: Wire PlacementRouter into factory + pass workload type
- What: Modify
SandboxProviderFactoryto preferPlacementRouterwhenSANDBOX_SERVERS_JSONis set. Add optionalworkloadTypehint togetOrCreateSandbox(). Pass it fromComputerUseLoopServicebased ongoal.persistentVm+ user role. - Files to modify:
sandbox-provider.interface.ts— Add optionalworkloadTypetogetOrCreateSandbox()sandbox-provider.factory.ts— Add PlacementRouter as priority 0infrastructure.module.ts— Register new providerscomputer-use-loop.service.ts— Pass workload type hint
- Why: Completes the multi-server integration
- Effort: 1 hour
- Dependencies: T2.1
Tier 3 — Large / Transformative Work
T3.1: IDS (Internal Dev Server) for executives
- What: Replace Firecracker VMs for executives with git worktrees + systemd-nspawn containers on AX42-U
- Why: 3-8s session start vs 5-11min, 2-3GB per session vs 12GB per VM, branch isolation instead of kernel isolation
- Effort: Full session (8+ hours)
- Dependencies: T2.1, T2.2
- Spec:
docs/superpowers/specs/2026-03-20-internal-dev-server-architecture-design.md
T3.2: Local AI inference on AX102-U
- What: Run quantized LLMs (Llama 3, Mistral) for routine tasks (embeddings, classification) at ~50x cheaper than API
- Why: Significant cost reduction for high-volume operations. Would need GPU server for full capability.
- Effort: Full session
- Dependencies: None (independent workstream)
6. The One Highest-Leverage Next Step
Build SandboxPlacementRouter (T2.1 + T2.2).
This is the critical missing piece that turns $248/mo of hardware from "one active, one standby" into "both active with intelligent routing." Every other feature (executive VMs, overflow, monitoring, future servers) depends on the router existing. Without it, the second server is a $124/mo health-check endpoint.
7. Files / Systems to Inspect
Core Files for SandboxPlacementRouter Implementation
APIs/backend-ai-api/src/app/infrastructure/services/sandbox/
├── sandbox-provider.interface.ts ← SandboxProvider interface (15 methods + 4 optional snapshot methods)
├── sandbox-provider.factory.ts ← Factory that selects provider (modify to add PlacementRouter)
├── self-hosted.provider.ts ← Current single-server HTTP client (extract client logic from this)
├── e2b-cloud.provider.ts ← E2B provider (reference for interface implementation)
├── playwright.provider.ts ← Playwright fallback (reference)
├── index.ts ← Barrel exports (add new exports)
└── [NEW FILES GO HERE] ← sandbox-placement-router.ts, etc.
Consumer Files (should NOT need changes, but verify)
APIs/backend-ai-api/src/app/infrastructure/services/computer-use-loop.service.ts
← Lines 306-349: sandbox creation flow, needs workload type hint
← Lines 356-391: cleanup flow (snapshot or destroy)
APIs/backend-ai-api/src/app/infrastructure/controllers/sandbox-proxy.controller.ts
← Lines 1-280: proxies to SelfHostedProvider directly for file ops
← WARNING: This controller injects SelfHostedProvider directly, not via factory!
← Must be updated to use PlacementRouter or factory
APIs/backend-ai-api/src/app/infrastructure/controllers/observatory.controller.ts
← Lines 82-137: sandbox-providers endpoint, reads from factory
APIs/backend-ai-api/src/app/infrastructure/infrastructure.module.ts
← Lines 282-285: Current provider registrations
← Lines 347: SandboxProviderFactory export
Configuration Files
.env.local ← SANDBOX_SERVERS_JSON already defined
libs/libs-foundational/api-config/proxy.conf.js ← Updated to ax102-01
deployment/sandbox-orchestrator/nginx.conf ← Fixed /git/ route + body size
deployment/sandbox-orchestrator/deploy-orchestrator-all.sh ← Fleet deploy
Critical Interface to Review
The SandboxProvider interface at sandbox-provider.interface.ts defines 15 required methods + 4 optional snapshot methods. The PlacementRouter must implement ALL of them. Key complexity:
getOrCreateSandbox()— must do server selection AND track placementscreenshot/click/type/etc.— must look up placement map, route to correct servergetUserVmState()— must query the correct server for the user's snapshotgetActiveSandboxes()— must aggregate across ALL serversdestroy()/destroySessionSandbox()— must route to owning server AND remove from placement map
IMPORTANT: SandboxProxyController Injects SelfHostedProvider Directly
sandbox-proxy.controller.ts has:
constructor(
private readonly factory: SandboxProviderFactory,
private readonly selfHostedProvider: SelfHostedProvider, // ← DIRECT INJECTION
)
The writeFile, readFile, injectSshKey methods call selfHostedProvider.writeFile() directly (not via factory). This must be updated to route through the PlacementRouter for file operations to work on the correct server.
8. Validation Checklist
Before Making Changes
-
git status— verify S260 uncommitted changes are present (nginx.conf, .env.local, bootstrap-server.sh, etc.) -
curl https://ax102-01.gograb.space/health— returnsstatus: ok -
curl https://sandbox.gograb.space/health— returnsstatus: ok -
curl -H "X-API-Key: hNwl3fka8Jc_xuUFvdvWin2IPMfI38sL5XuSpKQD1MI" https://ax102-01.gograb.space/sandboxes/meta/dashboard-summary— returns host metrics -
curl -H "X-API-Key: W8Wp0ltL8El_qRnXyoUoD0ffuSfZzmbn7Y7SVrx1k8s" https://sandbox.gograb.space/sandboxes/meta/dashboard-summary— returns host metrics
After Building PlacementRouter
-
npx tsc --noEmit --project APIs/backend-ai-api/tsconfig.app.json— type-checks clean -
npx nx build backend-ai-api— builds successfully - Backend-ai-api starts without errors
-
/infrastructure/hetznerdashboard shows BOTH servers as "running" - VM creation routes to ax102-01 (customer workload)
- VM operations (screenshot, click) work on created VM
- VM destruction cleans up placement map
-
getActiveSandboxes()aggregates across both servers - If ax102-01 is at capacity, overflow routes to ax42-01
Edge Cases to Test
- Server goes down mid-session → operations return clear error, don't crash
- Backend-ai-api restarts → placement map rebuilt from each server's
/sandboxeslist SANDBOX_SERVERS_JSONnot set → falls back toSANDBOX_SERVER_URL(backward compat)- Both servers at capacity → clear error message, no infinite retry
9. MCP and Codebase Guidance
Start the next session with:
preflight({ task: "S261: Build SandboxPlacementRouter for multi-server VM routing", sessionNumber: 261 })
find_similar({ query: "sandbox provider factory multi-server routing placement" })
intelligence({ action: "recall", situation: "SandboxPlacementRouter architecture S260" })
convention({ action: "get", category: "infrastructure" })
After implementation:
log_decision({ type: "implementation", question: "SandboxPlacementRouter implementation", ... })
outcome({ action: "report", decisionId: "dev_1774280349838_q915b9u", wasSuccessful: true/false })
Key past decisions to recall:
dev_1774275166101_7f7cvcj— Dual-server role assignmentdev_1774280349838_q915b9u— PlacementRouter architecture designdev_1772732288460_e0uh40p— S178.13: Missing fallback identified as known gapdev_1774028406609_8b11um0— S240: Snapshot-based per-user VM model
10. Server Fleet Reference
| ID | Hardware | IP | DNS | API Key | Roles |
|---|---|---|---|---|---|
| ax102-01 | AX102-U #2946196 (16C/32T, 128GB, 1.71TB) | 178.63.165.83 | ax102-01.gograb.space | hNwl3fka8Jc_xuUFvdvWin2IPMfI38sL5XuSpKQD1MI |
customer-sandbox, git-operations |
| ax42-01 | AX42-U #2938512 (8C/16T, 128GB, 1TB) | 144.76.69.55 | sandbox.gograb.space | W8Wp0ltL8El_qRnXyoUoD0ffuSfZzmbn7Y7SVrx1k8s |
executive-sandbox, customer-sandbox (overflow) |
SSH access:
- ax42-01:
ssh [email protected](key auth only) - ax102-01:
ssh [email protected](password:s2hD?J2EhBpAm5— MUST be changed)
COPY/PASTE PROMPT FOR SESSION 261
S261: Build SandboxPlacementRouter for multi-server VM routing.
CONTEXT:
Session 260 set up our second Hetzner server (AX102-U, ax102-01.gograb.space) and designed a
multi-server architecture. Both servers are running sandbox-orchestrator and healthy. But the
codebase still uses a SINGLE-SERVER SelfHostedProvider. We need a SandboxPlacementRouter that
routes workloads to the right server based on type, health, and capacity.
CURRENT STATE:
- ax102-01 (AX102-U, 32 CPUs, 60 VMs): RUNNING at https://ax102-01.gograb.space
- ax42-01 (AX42-U, 16 CPUs, 30 VMs): RUNNING at https://sandbox.gograb.space
- SANDBOX_SERVERS_JSON is defined in .env.local but NO code reads it yet
- SelfHostedProvider is single-URL, SandboxProviderFactory picks ONE provider
- SandboxProxyController injects SelfHostedProvider DIRECTLY (must be updated)
- All S260 config changes are UNCOMMITTED — commit first!
MISSION:
1. Commit all S260 changes: [S260.1] feat: Hetzner AX102-U setup + multi-server infrastructure config
2. Build SandboxPlacementRouter implementing SandboxProvider interface:
- sandbox-server-client.ts — HTTP client per server (extracted from SelfHostedProvider)
- sandbox-server-registry.ts — Parse SANDBOX_SERVERS_JSON
- sandbox-health-monitor.ts — Per-server health polling + circuit breaker
- sandbox-placement-map.ts — vmId→serverId sticky routing (in-memory, rebuild on restart)
- sandbox-placement-router.ts — Filter→Score→Place, implements all 19 SandboxProvider methods
3. Wire into SandboxProviderFactory (priority 0 when SANDBOX_SERVERS_JSON set)
4. Add workloadType hint to getOrCreateSandbox() — pass from ComputerUseLoopService
5. Fix SandboxProxyController to route file ops through PlacementRouter
6. Test: VM creation on ax102-01, operations work, overflow to ax42-01
KEY FILES:
- APIs/backend-ai-api/src/app/infrastructure/services/sandbox/ (all files)
- APIs/backend-ai-api/src/app/infrastructure/services/computer-use-loop.service.ts
- APIs/backend-ai-api/src/app/infrastructure/controllers/sandbox-proxy.controller.ts
- APIs/backend-ai-api/src/app/infrastructure/infrastructure.module.ts
- .env.local (SANDBOX_SERVERS_JSON config)
ARCHITECTURE (from S260 research):
- Filter→Score placement (K8s/Nomad inspired, simplified for 2-3 servers)
- Roles: customer-sandbox, executive-sandbox, git-operations
- Priority: ax102-01 = priority 1 for customer, ax42-01 = priority 2 (overflow)
- Sticky routing: placement map tracks which server owns each VM
- Health: per-server circuit breaker (healthy → degraded → down)
- Backward compat: if SANDBOX_SERVERS_JSON not set, fall back to SANDBOX_SERVER_URL
MCP DECISIONS TO RECALL:
- dev_1774280349838_q915b9u — PlacementRouter architecture design
- dev_1774275166101_7f7cvcj — Dual-server role assignment
WARNINGS:
- SandboxProxyController injects SelfHostedProvider DIRECTLY for writeFile/readFile/injectSshKey
- SandboxHandle doesn't include serverId — PlacementRouter must track this in its placement map
- getUserVmState() and restoreFromSnapshot() must query the correct server per user
- getActiveSandboxes() must aggregate across ALL servers
SPEC: docs/superpowers/specs/2026-03-23-hetzner-dual-server-infrastructure-design.md
HANDOFF: docs/english/Plans/S260: Hetzner Multi-Server Infrastructure and SandboxPlacementRouter.md
Generated by Claude Code (Session 260.1) — March 23, 2026